Exercises: Pure Functions and Side Effects

Consider the following Python program:

import random
import string
from datetime import datetime

def generate_id(length: int) -> str:
  return "".join(
    random.choice(string.ascii_uppercase + string.digits) for _ in range(length))

def weekday() -> str:
  today = datetime.today()
  return f"{today:%A}"

def main() -> None:
  print(f"Today is a {weekday()}")
  print(f"Your id = {generate_id(10)}")

if __name__ == "__main__":
  main()

a) Both generate_id and weekday are not pure functions. Why not? How would you write tests for these functions?

b) Rewrite both functions so that they are pure functions. Observe what happens to the main function after making this change. Are the functions now easier to test? Are they easier to use as well?

Compatible Python Versions: 3.6+


Dan Shirts

# Seems like there is both a temporal and a localization side effect in the weekday example?
def weekday(dt: datetime, language_encoding) -> str:
locale.setlocale(locale.LC_TIME, language_encoding)
return f"{dt:%A}"

weekday = weekday(datetime(1970, 1, 1, 0, 0, 0), language_encoding="en_US.UTF-8")
assert (weekday == "Thursday")

REPLY
Andreas [ArjanCodes Team]

That solution would indeed have a temporal and localization side effect. However, I can't seem to find the code that you are referring to.

In the solution given to this exercise, it does not change the localization.

REPLY
Dan Shirts

You are correct, the exercise should use the default English localization.

I'm overly sensitive to localization this week because a coworker changed localization, which caused my tests to fail in a completely separate module. :)

REPLY
Andreas [ArjanCodes Team]

Ah, I understand! That is unfortunate to hear. Out of curiosity, why do the tests depend on localization? Is that by design?

REPLY
Dan Shirts

Almost the same as the exercise. We generate a report that contains time strings, the report output is checked in testing.

We didnt realize that 'locale.setlocale()' changes localization globally (globally in the application).

REPLY
Andreas [ArjanCodes Team]

I see! Well, nice that you caught that and now can work around it :D

REPLY
Agustin Rodriguez

1)
import random
import string
from datetime import datetime
from typing import Callable

# def generate_id(length: int) -> str:
# return "".join(
# random.choice(string.ascii_uppercase + string.digits) for _ in range(length)
# )

def generate_id(length: int, custom_random_choice: Callable = random.choice) -> str:
return "".join(
custom_random_choice(string.ascii_uppercase + string.digits) for _ in range(length)
)

def weekday(today_fn: Callable = datetime.today) -> str:
today = today_fn()
return f"{today:%A}"

def main() -> None:
print(f"Today is a {weekday(lambda : datetime(2021, 1, 1))}")
print(f"Your id = {generate_id(10,lambda seq: seq[-1])}")

if __name__ == "__main__":
main()

2)
@dataclass
class Laptop:
machine_name: str = "DULL"

def install_os(self) -> None:
print("Installing OS")

def format_hd(self) -> None:
print("Formatting the hard drive")

def create_admin_user(self, password: str) -> None:
print(f"Creating admin user with password {password}.")

def reset_laptop(laptop: Laptop) -> None:
laptop.format_hd()
laptop.machine_name = "DULL"
laptop.install_os()
laptop.create_admin_user("admin")

# b) Write another version of the same program, but this time, don't extend the class with new capabilities,
# use a separate function instead. How would you describe the differences between the two versions?

@dataclass
class Laptop:
machine_name: str = "DULL"

def install_os(self) -> None:
print("Installing OS")

def format_hd(self) -> None:
print("Formatting the hard drive")

def create_admin_user(self, password: str) -> None:
print(f"Creating admin user with password {password}.")

def reset_laptop(self) -> None:
self.format_hd()
self.machine_name = "DULL"
self.install_os()
self.create_admin_user("admin")

REPLY
Andreas [ArjanCodes Team]

1. The functions are still not pure for the first solution because random.choice is not a deterministic function. Try to think of a way so it is not dependent on the execution of a non-deterministic function.

2. This solution looks good! Nice work!

REPLY
Agustin Rodriguez

def generate_id(length: int, custom_random_choice: Callable = random.choice) -> str:
return "".join(
custom_random_choice(string.ascii_uppercase + string.digits) for _ in range(length)
)
Sorry, why is it not deterministic? If I can send a sequence to choose, will it always return the same value?
It is like the weekday function.

REPLY
Andreas [ArjanCodes Team]

It is not deterministic because the function still relies on a non-deterministic function getting passed. The custom_random_choice callable by itself is non-deterministic because we cannot control the outcome. The sequence itself is not going to be the same. Try calling the function multiple times with the same inputs and you will see that the function will return different values.

REPLY
Agustin Rodriguez

Yes, I tested it with the lambda function lambda seq: seq[-1] and it always returns the last element of the list. But do you think the best solution is to pass a seed to the function?
def generate_id(length: int,seed :int = 0) -> str:
random.seed(seed)
return "".join(
random.choice(string.ascii_uppercase + string.digits) for _ in range(length)
)

REPLY
Andreas [ArjanCodes Team]

Oh, I see what the problem is. I mixed up the typing and the default value, that is my fault!

In that case, I would recommend removing the default value for custom_random_choice. Because you can get unwanted effects if forgotten to pass a callable.

REPLY
Andreas [ArjanCodes Team]

Oh, I see what the problem is. I mixed up the typing and the default value, that is my fault!

In that case, I would recommend removing the default value for custom_random_choice. Because you can get unwanted effects if forgotten to pass a callable.

REPLY
Andreas [ArjanCodes Team]

That in my view, would be the best solution!

REPLY
Luke Anderson

What would be the preferred approach for 2b? Modifying the class (isn't this violating the O in SOLID?), or creating a function that accepts the object and calls its relevant methods?

If using the OO approach, would we instead use inheritance to create a FacroryResetLaptop(Laptop) with a reset_to_factory method that calls the existing methods on the base class?

Or does this only really matter when it's an object that we cannot change, e.g. from a library?

REPLY
Andreas [ArjanCodes Team]

With the OO solution, you would not modify the class. Instead, you would extend the class, which does not violate the open-closed principle.

Using inheritance is a solution. However, by doing that, you are introducing a strong coupling between FacroryResetLaptop and Laptop , which is usually not a great idea. It would instead be better to use a method.

In terms of what we can change or not, if you are "resetting" an instance from an object of a library, you would most likely create a new object with new parameters. Or, use a function to reset the current instance.

REPLY
Tim Brennan

Hi, in the solution for exercise 1, is using the line SelectionFn = Callable[[], str] outside of the generate_id function instead of something like generate_id(length: int, sel_fn: Callable[[], str]) -> str represent better coding in general?

REPLY
Andreas [ArjanCodes Team]

Great question! Using SelectionFn = Callable[[], str] outside of the function lets us reuse the type annotation, which is preferable in this case because we want the partial to have the same type as the function that it is pre-populating.

If we need to update it, we only need to do it in one place and not two. It is a minor improvement in my view. If it was not reused in other parts of the code then I would recommend declaring it inline like you suggested

REPLY
Tim Brennan

ok thank you. that makes a lot of sense. we're saving ourselves an opportunity to make a mistake by only having the single place we need to update if we need to change something. thanks!

REPLY
Andreas [ArjanCodes Team]

Exactly! Glad it helped :)

REPLY
Kushagra Tiwari

Arjan, please review my answers.

REPLY
Show More